[SPARK-57555][FOLLOWUP][SQL] Add TimeType support to MySQLDialect#57198
[SPARK-57555][FOLLOWUP][SQL] Add TimeType support to MySQLDialect#57198shrirangmhalgi wants to merge 4 commits into
Conversation
499448d to
75cd5c7
Compare
MaxGekk
left a comment
There was a problem hiding this comment.
2 blocking, 1 non-blocking, 0 nits.
The read path is correct and well-tested, but the write path silently loses all fractional seconds for nanosecond TimeType. The one test that would catch it asserts the correct behavior yet fails against the current code on real MySQL — and isn't run in standard CI.
Correctness (2)
- MySQLDialect.scala:333: out-of-range fallback emits bare
TIME= MySQLTIME(0), losing all fractional seconds forTimeType(7/8/9); should emitTIME(6)— see inline - MySQLIntegrationSuite.scala:435: nanosecond write test asserts the correct
TimeType(6)/microsecond round-trip, which the currentTIME(0)fallback can't satisfy — it would fail on real MySQL, and it's a@DockerTestnot run in standard CI — see inline
Suggestions (1)
- MySQLDialect.scala:228:
getLong("datetimePrecision")throws if the key is absent; currently safe but fragile — see inline
Verification
Traced read/write symmetry against MySQL's bare-TIME = TIME(0) semantics. The PR's own read test (inserts 13:31:24.123 into a bare TIME column, asserts read-back 13:31:24 / TimeType(0)) confirms bare TIME drops fractional seconds — so the write else-branch's bare TIME for precision>6 loses all sub-second data rather than truncating to micros. The updateExtraColumnMeta→getCatalystType ordering in JdbcUtils.getSchema (call at line 331 before line 334) makes the read path's metadata read safe.
| // MySQL supports TIME(p) for p in [0,6]. Use the declared precision when valid; | ||
| // fall back to bare TIME (= TIME(6)) for out-of-range precisions (e.g. nanosecond). | ||
| val p = t.precision | ||
| if (p >= 0 && p <= 6) Option(JdbcType(s"TIME($p)", java.sql.Types.TIME)) | ||
| else Option(JdbcType("TIME", java.sql.Types.TIME)) |
There was a problem hiding this comment.
This out-of-range fallback loses all fractional seconds, not just the sub-microsecond part. TimeType.MAX_PRECISION is 9, so TimeType(7|8|9) reaches the else and emits bare TIME — which in MySQL is TIME(0) (whole seconds), as this PR's own read test confirms (inserting 13:31:24.123 into a bare TIME column reads back 13:31:24). So 12:00:00.123456789 is stored as 12:00:00, not truncated to microseconds. MySQL's max fractional precision is 6, so the fallback should emit TIME(6):
| // MySQL supports TIME(p) for p in [0,6]. Use the declared precision when valid; | |
| // fall back to bare TIME (= TIME(6)) for out-of-range precisions (e.g. nanosecond). | |
| val p = t.precision | |
| if (p >= 0 && p <= 6) Option(JdbcType(s"TIME($p)", java.sql.Types.TIME)) | |
| else Option(JdbcType("TIME", java.sql.Types.TIME)) | |
| // MySQL supports TIME(p) for p in [0,6]. Use the declared precision when valid; | |
| // fall back to TIME(6) (MySQL's max fractional precision) for out-of-range | |
| // precisions (e.g. nanosecond), which truncates to microseconds rather than | |
| // to whole seconds (bare TIME = TIME(0) in MySQL). | |
| val p = t.precision | |
| if (p >= 0 && p <= 6) Option(JdbcType(s"TIME($p)", java.sql.Types.TIME)) | |
| else Option(JdbcType("TIME(6)", java.sql.Types.TIME)) |
| assert(result.schema("t_nanos").dataType === TimeType(6)) | ||
| // Nanoseconds truncated to microseconds: 123456789 -> 123456000 | ||
| checkAnswer(result, Row(LocalTime.of(12, 0, 0, 123456000))) |
There was a problem hiding this comment.
These assertions are correct — TimeType(6) with microseconds preserved is what a nanosecond write should round-trip to. But they don't hold against the current code: getJDBCType emits bare TIME (= MySQL TIME(0)) for TimeType(9), so the column stores whole seconds and this reads back as TimeType(0) / 12:00:00. On a real MySQL server both assertions would fail. This wasn't caught because the suite is @DockerTest and isn't part of standard PR CI (the description only reports the JDBCSuite unit run). Once the getJDBCType fix above lands, these assertions become correct as written — just fix the comment premise below.
| // MySQL TIME max precision is 6 (microseconds). TimeType(9) writes as bare TIME (= TIME(6)), | ||
| // and the nanosecond portion is truncated on read-back. |
There was a problem hiding this comment.
The premise here is wrong in the same way as the production comment: bare MySQL TIME is TIME(0), not TIME(6). With the getJDBCType fix (emit TIME(6) for out-of-range precision), the accurate statement is:
| // MySQL TIME max precision is 6 (microseconds). TimeType(9) writes as bare TIME (= TIME(6)), | |
| // and the nanosecond portion is truncated on read-back. | |
| // MySQL TIME max precision is 6 (microseconds). TimeType(9) writes as TIME(6), | |
| // and the sub-microsecond portion is truncated on read-back. |
| // MySQL Connector/J Bug #84308: COLUMN_SIZE=8 and DECIMAL_DIGITS=0 for all TIME columns | ||
| // regardless of declared precision. The actual precision is injected by | ||
| // updateExtraColumnMeta via INFORMATION_SCHEMA query. | ||
| val precision = md.build().getLong("datetimePrecision").toInt |
There was a problem hiding this comment.
MetadataBuilder.getLong throws NoSuchElementException if datetimePrecision is absent. It's safe today — JdbcUtils.getSchema always calls updateExtraColumnMeta before getCatalystType, and every branch there sets the key for a TIME column — but coupling this read to an unrelated method having run first is fragile. Consider guarding it:
| val precision = md.build().getLong("datetimePrecision").toInt | |
| val md0 = md.build() | |
| val precision = if (md0.contains("datetimePrecision")) { | |
| md0.getLong("datetimePrecision").toInt | |
| } else { | |
| TimeType.DEFAULT_PRECISION | |
| } |
(Non-blocking.)
Add TimeType support to MySQLDialect: - getCatalystType: maps Types.TIME to TimeType(precision) when timeType.enabled=true and legacyJdbcTimeMappingEnabled=false. Precision is derived from JDBC metadata (scale parameter). - getJDBCType: maps TimeType(p) to TIME(p) DDL for write path. - Integration tests: read round-trip, write round-trip, and legacy escape hatch verification.
…clamping, proactive fixes
- getCatalystType: use md.build().getLong("scale") for fractional-second
precision (getScale reports DECIMAL_DIGITS, not display width). Accept
scale=0 for bare TIME (TIME(0) is valid).
- getJDBCType: use TIME(p) for p<=6, bare TIME for out-of-range
precisions (e.g. nanosecond TimeType), matching PostgreSQL pattern.
- Fix read test: bare TIME column reports scale=0, truncates fractional
seconds (value 13:31:24 not 13:31:24.123).
- Add precision preservation round-trip test (TimeType(3) -> TIME(3))
- Add nanosecond TimeType(9) write test (MySQL truncates to micros)
- Add comment explaining precision source from JDBC metadata
…SION MySQL Connector/J Bug #84308: getPrecision()=8 and getScale()=0 for all TIME columns regardless of declared precision. Override updateExtraColumnMeta to query INFORMATION_SCHEMA.COLUMNS.DATETIME_PRECISION and inject the real precision into the metadata, which getCatalystType then reads.
…comment
- getJDBCType: emit TIME(6) instead of bare TIME for out-of-range
precisions (bare TIME = TIME(0) loses all fractional seconds)
- getCatalystType: guard getLong('datetimePrecision') with contains()
check, fallback to DEFAULT_PRECISION if key absent
- Fix test comment: TimeType(9) writes as TIME(6), not bare TIME
75cd5c7 to
38cf5f9
Compare
What changes were proposed in this pull request?
Add
TimeTypesupport toMySQLDialectand precision-preserving DDL for writes:getCatalystType: mapTypes.TIMEtoTimeType(scale)whenspark.sql.timeType.enabledis true, gated bylegacyJdbcTimeMappingEnabledgetJDBCType: addTimeType => TIME(p)for p in [0,6], bare TIME for out-of-range precisions (e.g. nanosecond TimeType)Why are the changes needed?
This is a followup to #56653 which added core JDBC TIME support in
JdbcUtils. That PR handled scalar read/write correctly via the generic path, but:getJDBCTypein the base JdbcUtils emitsTIME(${t.precision})which produces TIME(9) for nanosecond TimeTypes - MySQL rejects precisions > 6getCatalystTyperuns before the genericJdbcUtilsmapper (confirmed in [SPARK-57555][FOLLOWUP][SQL] Add escape-hatch conf to keep legacy JDBC TIME-to-timestamp mapping #56884), so MySQL needs its own TIME handling gated by the escape-hatch configDoes this PR introduce any user-facing change?
Yes. When
spark.sql.timeType.enabledis true, MySQLTIMEandTIME(p)columns now correctly read asTimeTypeand write with precision-preservingTIME(p)DDL. Non-TIME columns are unaffected.How was this patch tested?
Added integration tests in
MySQLIntegrationSuite:TIMEcolumn is read asTimeType(0)andTIME(3)asTimeType(3)with correct valuesTimeType(0)andTimeType(6)values and reads them backTimeType(3)->TIME(3)-> read back asTimeType(3)TimeType(9)-> bareTIME-> read back asTimeType(6)with microsecond truncationTIMEreads as non-TimeType whenlegacyJdbcTimeMappingEnabled=trueUnit tests: JDBCSuite (127 tests, all passing).
Was this patch authored or co-authored using generative AI tooling?
CoAuthored using Claude Opus 4.6